昨天,我們讓 createFixedList 在瀏覽器渲染之前就替每一列算好位置。不過,第 3 列一折行,兩列就重疊了。它從瀏覽器拿到的只有 scrollTop,第 3 列實際多高,它沒有地方收下。
所以要量。可是量測要等元素畫出來才量得到,量到之後,又會改變下一次要畫什麼。
要接起這個循環,至少得先回答兩件事:要請瀏覽器看著誰?量到的數字,又怎麼記?
讓我們先從比較直覺的作法開始:哪一列要量,就看著哪一列。
還沒畫之前,我們仍然不知道一列多高,只能先估一個數字。這裡估 40px,比實際的 50px 少一點。
量到之後,把結果記進一個 Map,以列的索引當 key;沒量過的列,才用估計值。這個 Map 從外面傳進來,好讓我們等一下印出它記了幾筆。
清單不必知道這個 Map 的所有用法,只需要兩件事:get 讀出某一列記下的尺寸,set 寫入量到的尺寸。另外留著 size,只是為了等一下印出記了幾筆。我們先把它們寫成型別:
// measured-list.ts
type Sizes = {
get(index: number): number | undefined;
set(index: number, size: number): void;
readonly size: number;
};
type Range = { start: number; end: number };
type MeasuredListOptions = {
count: number;
estimateSize: number;
overscan: number;
sizes: Sizes;
};
建立清單的參數與昨天大致相同:count 是總列數,overscan 是視窗上下各多畫幾列。Range 是要畫的範圍,start 含、end 不含。原本固定的列高換成估計值 estimateSize,再多傳一個 sizes。
每一列不再一樣高,所以先要有 sizeOf,查某一列多高:記過就用記下的,沒記過才用估計值。第 index 列的上緣 offsetOf 也不能再用固定列高乘上索引算,而是把它前面的列高加起來:
// measured-list.ts
export function createMeasuredList({
count,
estimateSize,
overscan,
sizes,
}: MeasuredListOptions) {
const sizeOf = (index: number): number => sizes.get(index) ?? estimateSize;
const offsetOf = (index: number): number => {
let offset = 0;
for (let i = 0; i < index; i += 1) offset += sizeOf(i);
return offset;
};
const range = (scrollTop: number, viewportHeight: number): Range => {
let first = 0;
while (first < count - 1 && offsetOf(first + 1) <= scrollTop) first += 1;
let last = first;
while (last < count && offsetOf(last) < scrollTop + viewportHeight)
last += 1;
return {
start: Math.max(0, first - overscan),
end: Math.min(count, last + overscan),
};
};
return {
range,
sizeOf,
offsetOf,
totalSize: (): number => offsetOf(count),
measure: (index: number, size: number): void => {
sizes.set(index, size);
},
};
}
range 的工作和昨天一樣,找出碰到視窗的列,再往上下各多加 overscan 列。只是這次必須逐列比對位置。
totalSize 是整份清單的總高度,用來撐開捲動容器,現在等於最後一列下緣的位置。measure 是留給瀏覽器那一側的入口,量到多少,就記下多少。
接著要決定,誰來量。
瀏覽器提供了 ResizeObserver API:把元素交給它觀察,元素的尺寸一有變化,它就會在下一次畫面繪製之前回呼,告訴我們新的尺寸。這裡的列掛上 DOM 並開始被觀察後,也會收到初次回報。
回呼收到的是一串 entries,每一筆對應一個尺寸變了的元素:entry.target 是那個元素,entry.borderBoxSize[0].blockSize 是它連同內距與邊框在內的高度。
先準備資料與清單。沿用昨天的設定,視窗高 200px,每一列上下各有 12px 內距、行高 26px,所以一行字的列是 12 + 26 + 12 = 50px。索引為 3 的列文字比較長,在這次實驗的字型與寬度下,會折成三行。
// List.tsx
import { useCallback, useReducer, useState } from "react";
import { createMeasuredList } from "./measured-list";
const VIEWPORT_HEIGHT = 200;
const rowStyle = { padding: "12px 16px", lineHeight: "26px" };
const rows = Array.from({ length: 20 }, (_, i) =>
i === 3
? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
: `第 ${i} 列`,
);
const sizes = new Map<number, number>();
const list = createMeasuredList({
count: rows.length,
estimateSize: 40,
overscan: 1,
sizes,
});
然後在元件裡建立 ResizeObserver。一般的重新渲染不需要換一個 observer,所以把它放進 useState 的初始化函式,只在首次渲染時建立。回呼從每一列的 data-index 認出是第幾列,把量到的高度交給 measure。
記下的尺寸存在元件之外的 sizes,React 不知道它變了,所以我們另外用 useReducer 做一個 rerender,它唯一的用途是觸發重新渲染:
// List.tsx
export const List = () => {
const [scrollTop, setScrollTop] = useState(0);
const [, rerender] = useReducer((n: number) => n + 1, 0);
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
for (const entry of entries) {
const row = entry.target as HTMLElement;
const index = Number(row.dataset.index);
const size = entry.borderBoxSize[0]?.blockSize;
if (size === undefined) continue;
console.log(`量到第 ${index} 列:${size}px`);
list.measure(index, size);
}
rerender();
}),
);
const observe = useCallback(
(row: HTMLDivElement) => {
observer.observe(row, { box: "border-box" });
return () => observer.unobserve(row);
},
[observer],
);
const { start, end } = list.range(scrollTop, VIEWPORT_HEIGHT);
console.log(`畫出第 ${start}–${end - 1} 列,記下 ${sizes.size} 筆`);
return (
<div
style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
>
<div style={{ position: "relative", height: list.totalSize() }}>
{rows.slice(start, end).map((text, i) => {
const index = start + i;
return (
<div
key={index}
ref={observe}
data-index={index}
style={{
...rowStyle,
position: "absolute",
top: list.offsetOf(index),
left: 0,
right: 0,
}}
>
{text}
</div>
);
})}
</div>
</div>
);
};
每一列掛上 DOM 時,ref={observe} 把它交給 observer;卸下時,回傳的清理函式取消觀察。這裡使用的是 React 19 支援的 ref 清理函式。
useCallback 讓一般的重新渲染維持同一個 ref 函式,避免因函式身分改變,把原本正在觀察的列取消再重新觀察。
打開頁面,主控台印出:
畫出第 0–5 列,記下 0 筆
量到第 0 列:50px
量到第 1 列:50px
量到第 2 列:50px
量到第 3 列:102px
量到第 4 列:50px
量到第 5 列:50px
畫出第 0–4 列,記下 6 筆
第一次渲染時,每一列都假設是 40px,200px 的視窗裝得下 5 列,再加上下方一列過掃描,所以畫出第 0–5 列。
這 6 列開始被觀察後,量到了各自的高度。第 3 列是 102px,第 4 列的上緣因此變成 3 × 50 + 102 = 252px。重新計算時,第 0–3 列已經填滿視窗,再加一列過掃描,畫出的是第 0–4 列。
第 4 列接在第 3 列的正下方,昨天的重疊不見了。
💡 這是為了看清楚量測與儲存的最小實驗。
offsetOf每次從頂端加總,range又反覆呼叫它,並不適合直接拿來處理大量資料。模組層的sizes與list也只是方便觀察,並未隔離同一元件的多個實例。正式實作還要處理生命週期、隱藏元素、排列方向與捲動效能等問題。
再慢慢往下捲到底,每一列進到畫面時都被量一次,主控台最後一行是:
畫出第 15–19 列,記下 20 筆
位置都對了。可是這二十筆裡,有十九筆記的是同一個數字:50。
這個 Map 替每一列都留了一格,不要求任何兩列一樣高。如果資料經常是相鄰幾列一樣高,是否有別的記法?
先把這個問題放一下。我們還有另一件事要比較:一定得看著每一列嗎?
每一列各自被看著,回報的就是那一列自己;另一種作法反過來,不看每一列,只看裝著它們的容器。
要這樣做,容器得在某一列變高時跟著變高。昨天的 PlainList 已經讓我們看過:列留在正常的文件流裡,第 3 列變高,就會把後面的列往下推,裝著它們的元素也跟著長高。絕對定位的列不占文件流的空間,做不到這件事。
所以這一版不用絕對定位。畫出來的列照順序排在一個容器裡,容器上方墊一段 paddingTop,高度是第一個被畫出來的列之前所有列的總和,讓第 start 列落在它該在的位置。
但 observer 只會告訴我們「容器尺寸變了」,不會說是哪一列。我們得走訪容器裡的列,自己讀它們的 offsetHeight,再判斷哪些和目前記下的尺寸不同。
那麼,比對的值從哪裡拿?
直覺上可以回頭查 sizes。不過 react-virtuoso 的作法不太一樣:畫一列時,把這次渲染採用的尺寸寫進 DOM 屬性 data-known-size。
// react-virtuoso: src/Virtuoso.tsx
data-known-size={item.size}
量的時候,getChangedChildSizes 拿到的是容器的子元素 children。沒有 data-index 的不是列,跳過;讀到的尺寸與 data-known-size 一樣,也跳過:
// react-virtuoso: src/hooks/useChangedChildSizes.ts
for (let i = 0; i < length; i++) {
const child = children.item(i) as HTMLElement
if (child.dataset.index === undefined) {
continue
}
const index = parseInt(child.dataset.index, 10)
const knownSize = parseFloat(child.dataset.knownSize!)
const size = itemSize(child, field)
// ⋯⋯尺寸為 0 時,記一筆錯誤
if (size === knownSize) {
continue
}
// ⋯⋯尺寸變了的列,收進 results
}
itemSize(child, field) 讀的是捲動方向上的尺寸;本文的直向清單使用高度。offsetHeight 是整數,因此它和前一版讀到的 border box 尺寸,在有小數尺寸時不一定完全相同。
這個函式拿到的只有容器的 children。哪一列是第幾列,讀 data-index;上次記下多少,讀 data-known-size。尺寸實際存在哪裡,它不必知道。
尺寸仍由清單那一側保管。DOM 上放的是供量測比對的值,像貼在元素身上的備忘錄。
我們照這個形狀寫一版 ContainerList。設定與上一版相同,只建立另一個 Map 與 list,讓兩份實驗各記各的。scrollTop、rerender 與外層捲動容器也沿用上一版。
換掉的第一部分是 observer 回呼:
// ContainerList.tsx:元件內
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
const container = entries[0]?.target;
if (!(container instanceof HTMLElement)) return;
let reported = 0;
for (const child of container.children) {
const row = child as HTMLElement;
const size = row.offsetHeight;
if (size === Number(row.dataset.knownSize)) continue;
list.measure(Number(row.dataset.index), size);
reported += 1;
}
console.log(`讀了 ${container.children.length} 列,回報 ${reported} 列`);
if (reported > 0) rerender();
}),
);
const observe = useCallback(
(container: HTMLDivElement) => {
observer.observe(container, { box: "border-box" });
return () => observer.unobserve(container);
},
[observer],
);
第二部分是內層 JSX:列不再絕對定位,ref 移到容器,每一列加上 data-known-size。
// ContainerList.tsx:外層捲動容器內
<div style={{ height: list.totalSize() }}>
<div ref={observe} style={{ paddingTop: list.offsetOf(start) }}>
{rows.slice(start, end).map((text, i) => {
const index = start + i;
return (
<div
key={index}
data-index={index}
data-known-size={list.sizeOf(index)}
style={rowStyle}
>
{text}
</div>
);
})}
</div>
</div>
為什麼觀察容器時指定 box: "border-box"?
假設畫出的 7 列都是 50px。往下捲一列,上方一列離開、下方一列進來,內容高度仍是 350px。若只觀察 content box,這次替換不會因內容高度而觸發回呼。
但 paddingTop 多了 50px。把內距也算進去的 border box 會變高,就能讓這份實驗重新讀取子列尺寸。
這不代表觀察容器就能捕捉每一次子列變化。如果一列變高、另一列同時變矮,總高度恰好不變,容器的尺寸可能沒有變化,也就不能只靠這個通知知道發生了什麼。
打開頁面:
畫出第 0–5 列,記下 0 筆
讀了 6 列,回報 6 列
畫出第 0–4 列,記下 6 筆
讀了 5 列,回報 0 列
第一次回呼時,6 列的 data-known-size 都是估計值 40,讀到的都不是 40,所以全部回報。重新渲染後,第 5 列不再畫出,容器變矮,回呼又來一次。這次每一列的比對值都已更新,一列都沒有回報,也不需要再觸發渲染。
眼前這次循環停在這裡。但我們只看到了這份資料的結果,還沒有證明所有量測與渲染都一定會如此收斂。
往下捲時,每次回呼讀的是當下畫出的所有列,回報的通常只有新進畫面的那一兩列:
讀了 7 列,回報 1 列
讀了 7 列,回報 2 列
兩種作法量到的數字相同,差別在代價落在哪裡:
逐列觀察,被觀察的元素是每個畫出來的列;只觀察容器,被觀察的只有一個容器,但每次回呼都要把畫出來的每一列都讀一次 offsetHeight。
探索共享狀態時,我們問過通知該切得多細;這一次,粒度的代價落在 DOM 上:觀察多少個元素,以及每次通知後要讀取多少列的版面資訊。
捲到底之後,這一版也記下了 20 筆,其中十九筆是 50。被觀察的換了,尺寸仍然一列記一筆。
接下來換的是儲存方式。
量測這一層只負責把數字交給 list.measure,不必知道尺寸資料的內部結構。所以觀察方式可以保留,儲存方式單獨換掉。
回頭看 getChangedChildSizes 剛才略過的那一段,也就是尺寸變了的列,怎麼收進 results:
// react-virtuoso: src/hooks/useChangedChildSizes.ts
const lastResult = results[results.length - 1]
if (results.length === 0 || lastResult!.size !== size || lastResult!.endIndex !== index - 1) {
results.push({ endIndex: index, size, startIndex: index })
} else {
results[results.length - 1]!.endIndex++
}
每一筆是一段 SizeRange,記著從第幾列到第幾列,都是多高。這一列若與上一段同高,又緊接在它後面,就把上一段延長;否則另起一段。
所以回報不一定一列一筆:相鄰且同高的列,會被併成一段。react-virtuoso 的尺寸結構也能表示這樣的連續區間。
讓我們寫一個以段為單位的 sizes。這裡的 startIndex 與 endIndex 都包含在段內,和前面渲染範圍的 end 不同。
// range-sizes.ts
export type SizeRange = {
startIndex: number;
endIndex: number;
size: number;
};
寫入一列時分兩步。第一步是拆:走訪每一段,沒有包含 index 的段原樣留下;包含它的段,切掉 index 那一列,前後剩下的部分各自成段。最後放進新寫入的這一列,依起點排好:
// range-sizes.ts
export const withSize = (
ranges: readonly SizeRange[],
index: number,
size: number,
): SizeRange[] => {
const pieces: SizeRange[] = [];
for (const r of ranges) {
if (index < r.startIndex || r.endIndex < index) {
pieces.push(r);
continue;
}
if (r.startIndex < index)
pieces.push({
startIndex: r.startIndex,
endIndex: index - 1,
size: r.size,
});
if (index < r.endIndex)
pieces.push({
startIndex: index + 1,
endIndex: r.endIndex,
size: r.size,
});
}
pieces.push({ startIndex: index, endIndex: index, size });
pieces.sort((a, b) => a.startIndex - b.startIndex);
第二步是併:依序走過每一段,若與前一段首尾相接、而且同高,就把前一段延長;否則另起一段。這和 getChangedChildSizes 收 results 的判斷是同一件事:
// range-sizes.ts
const merged: SizeRange[] = [];
for (const piece of pieces) {
const last = merged[merged.length - 1];
if (
last &&
last.endIndex + 1 === piece.startIndex &&
last.size === piece.size
)
merged[merged.length - 1] = { ...last, endIndex: piece.endIndex };
else merged.push(piece);
}
return merged;
};
再把它包成與 Map 相同的形狀:get、set,以及代表記了幾筆的 size,讓 createMeasuredList 能沿用相同的入口:
// range-sizes.ts
export function createRangeSizes() {
let ranges: SizeRange[] = [];
return {
get: (index: number): number | undefined =>
ranges.find((r) => r.startIndex <= index && index <= r.endIndex)?.size,
set: (index: number, size: number): void => {
ranges = withSize(ranges, index, size);
},
get size(): number {
return ranges.length;
},
};
}
兩個元件各自匯入 createRangeSizes,再把建立 sizes 的那一行換掉:
// List.tsx、ContainerList.tsx
const sizes = createRangeSizes();
位置計算依賴 get,量測寫入依賴 set,所以不用跟著換。只有 log 裡的 size 換了意思:原本數的是量過幾列,現在數的是存了幾段。
同樣慢慢捲到底,主控台的最後一行變成:
畫出第 15–19 列,記下 3 筆
三段分別是:第 0–2 列 50px、第 3 列 102px、第 4–19 列 50px。
同樣的二十列,Map 記了 20 筆,段只記了 3 筆。多出來的那 17 筆,記的都是「這一列和旁邊的一樣高」。
接著試試看,之後資料變了,兩邊會怎麼樣。把 rows 改成在元件裡由狀態產生,建立清單時的 count 固定為 20,再加一顆切換按鈕:
// List.tsx、ContainerList.tsx
// 模組層:取代原本的 rows
const rowsOf = (long: boolean) =>
Array.from({ length: 20 }, (_, i) =>
i === 3 && long
? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
: `第 ${i} 列`,
);
// 元件裡
const [long, setLong] = useState(true);
const rows = rowsOf(long);
// 放在捲動容器前,兩者用 Fragment 包起來
<button onClick={() => setLong((l) => !l)}>切換第 3 列</button>
量過所有列後,捲回頂端,按一下按鈕,第 3 列變回一行字、50px。
Map 把第 3 列那一格改成 50,還是 20 筆。段這邊,第 3 列一變成 50,就與前後兩段接成一片,三段併成一段:第 0–19 列 50px。
再換一份資料,讓列高依序重複 50px、76px、102px:
// List.tsx、ContainerList.tsx
// 模組層:取代原本的 rows
const rows = Array.from({ length: 20 }, (_, i) =>
i % 3 === 0
? `第 ${i} 列`
: i % 3 === 1
? `第 ${i} 列:這一列稍微長一點,會折成兩行。`
: `第 ${i} 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。`,
);
相鄰列都不同高,捲到底後就是一列一段,區間版也記了 20 段。
第三份資料也有規律,只是這種規律不能靠「相鄰同高」壓縮。
同一份量測結果,存成兩種形狀,記下的數量可以差很多。差別不在量到了什麼,而在相鄰列的尺寸像不像。
怎麼存,反映了實作預期資料會呈現什麼形狀,也決定某一列變高時,要付出多少更新成本。
一列一筆的 Map,不要求相鄰列有共同尺寸。在我們這份每次量到都寫入的版本裡,記多少取決於量過幾列;某一列之後變了,也只更新那一格尺寸。
一段一筆的區間,押的是相鄰列經常一樣高,能連成較長的區段。記多少,取決於這些列形成幾段;相鄰尺寸切換得越頻繁,段就越多。
押中的時候,第 3 列一變回 50px,三段就收成一段;押錯的時候,區間退化成一列一段,寫入仍然得維護段的拆分與合併。我們這份簡化實作甚至每次都重新走訪、排序,沒有把更新成本藏掉。
原作在這兩個方向上,又多做了一些事。
本文引用的 TanStack Virtual 實作以每一列的 key 為索引,把尺寸存在 itemSizeCache 裡,並在量到的尺寸與目前採用的尺寸不同時才更新。第一次量到的值若正好等於估計值,就可能不必新增一筆尺寸紀錄。呼叫端透過 estimateSize 提供的預估,也會影響這份資料如何累積。
react-virtuoso 則把尺寸的區間邊界存在 sizeTree 裡。每個節點記著「從第幾列開始,是多高」,直到下一個邊界為止。在本文引用的初始化路徑中,尺寸還沒有任何紀錄時,會把首次回報中第一段的尺寸寫到索引 0:
// react-virtuoso: src/sizeSystem.ts
sizeTree = insert(sizeTree, 0, size)
這等於先替尚未量過的列採用一個共同尺寸,再用後續量到的結果修正。和我們只合併已量測區間的版本相比,它還把這個預期延伸到沒畫過的列。
一邊讓呼叫端提供每一列的估計;另一邊能從首次量到的結果建立共同尺寸。兩邊都得面對未知,只是取得起點的方式不同。
不管尺寸怎麼記,量到不一樣的那一刻,位置都得重新算。剛才把第 3 列切回長的,它從 50px 變成 102px,下面每一列的計算位置都往下移了 52px。
如果之後重新量到視窗上方的一列,才發現它比原先記下的更高,而你正在讀第 10 列呢?
列的位置算對了,你正在讀的內容,還能留在原來的地方嗎?
文章所用的完整程式碼:Notion
原作的出處:react-virtuoso 的
useChangedChildSizes.ts(getChangedChildSizes)、Virtuoso.tsx(data-known-size)、sizeSystem.ts(insertRanges);TanStack Virtual 的virtual-core/src/index.ts(itemSizeCache、resizeItem)。取自預設分支原始碼,npm 最新版為react-virtuoso4.18.16、@tanstack/virtual-core3.17.11,查核於 2026-10-08。本文的實驗跑在
react19.3.0 與react-dom19.3.0,Chrome,字型 PingFang TC 16px,查核於 2026-10-09。